iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Lambda 從零開始系列 第 32

Kotlin Lambda 從零開始 Day 32:Scope Functions 與 Collection 的組合技

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻五大 Scope Functions(letrunwithapplyalso),搞清楚「帶 Receiver 的 Lambda」是什麼,再看它們怎麼跟 Collection 操作搭配

Kotlin ↔ C# 對照表

Kotlin C# 備註
obj.let { } 無直接對應 C# 靠 extension method 模擬
obj.run { } 無直接對應 類似 let,但用 this
with(obj) { } 無直接對應 頂層函式,非擴充
obj.apply { } 無直接對應 物件初始化常用
obj.also { } 無直接對應 類似 C# 的 Tap 擴充

C# 沒有 scope function 的概念。有些開發者會自己寫 TapPipe 擴充方法來模擬,但不是語言內建的

五大 Scope Functions 速覽

函式 Lambda 參數 回傳值 典型用途
let it Lambda 結果 null 安全鏈、轉換
run this Lambda 結果 物件初始化 + 計算
with this Lambda 結果 對同一物件多次操作
apply this 物件本身 物件初始化
also it 物件本身 副作用(logging、debug)

五個函式的差別就在兩個重點:Lambda 裡面用 it 還是 this,以及回傳 Lambda 結果還是物件本身

為什麼 Kotlin 需要五個 Scope Functions

這五個都是 Kotlin stdlib 函式,不是新的語言語法。它們利用既有的擴充函式與帶 Receiver Lambda,整理出幾種常用組合

差異在 receiver 的設計(day 03 講擴充函式時介紹過 receiver 的概念)

C# 的 extension method 是 static 函式加上一點語法糖。this T 參數讓呼叫端寫起來像實例方法,但 Lambda 內部沒有「receiver」這個概念,Lambda 只能透過明確命名的參數存取物件。自己寫一個 Tap

public static T Tap<T>(this T value, Action<T> action) {
    action(value);
    return value;
}

// 呼叫端(示意,實際要寫在方法內)
obj.Tap(it => it.DoSomething());  // 必須寫 it.DoSomething()

Kotlin 的 T.() -> R 把 receiver 帶進 Lambda 內部,this 直接是 receiver,可以省略

obj.apply { doSomething() }  // 不需要寫 this. 或 it.

差別累積起來就不一樣。物件分配、集合初始化、多個 setter 串起來,Kotlin 寫法明顯乾淨

至於為什麼要五個而不是一個,是 Kotlin 把兩個正交的軸切開了

  • Lambda 內部用 this 還是 itthis 適合對同一物件做多次操作,it 適合需要明確命名的情況(尤其物件是 nullable)
  • 回傳 Lambda 結果還是物件本身,回傳結果適合「轉換」,回傳物件本身適合「副作用 + 鏈式」

兩個軸交叉是四種組合,加上 with 是頂層函式版的 run,湊成五個。每一個都對應一種真實使用情境,不是為多而多

帶 Receiver 的 Lambda:T.() -> R

這是理解 scope function 差異的核心

// 普通 Lambda:T 當參數,用 it 存取
val normalLambda: (String) -> Int = { it.length }

// 帶 Receiver 的 Lambda:T 當 receiver,用 this 存取(可省略)
val receiverLambda: String.() -> Int = { length }

(T) -> RT.() -> R 的功能一樣,差在語法。帶 Receiver 的 Lambda 裡面可以直接呼叫 receiver 的方法,不需要寫 it.

這就是 let(用 it) 和 run(用 this) 的根本差異

TDD 實作 myLet 與 myRun

Red:先寫測試

@Test
fun `let transforms value`() {
    val result = "hello".myLet { it.length }
    assertEquals(5, result)
}

@Test
fun `let with null safety`() {
    val name: String? = "Kotlin"
    val result = name?.myLet { "Hello, $it" }
    assertEquals("Hello, Kotlin", result)
}

@Test
fun `run uses this as receiver`() {
    val result = "hello".myRun { length }
    assertEquals(5, result)
}

myLet 的 Lambda 用 it.lengthmyRun 的 Lambda 直接寫 length。因為 run 的 Lambda 是 T.() -> R,receiver 就是 "hello" 本身

Green:最小實作

inline fun <T, R> T.myLet(block: (T) -> R): R {
    return block(this)
}

inline fun <T, R> T.myRun(block: T.() -> R): R {
    return block()
}

兩個函式各一行。差別只在 Lambda 的型別宣告

myLet 接收 (T) -> R,把 this 當參數傳進去。myRun 接收 T.() -> R,直接呼叫 block(),因為 receiver 已經是 this

Refactor:往 stdlib 的寫法靠近

兩個函式都是單一運算式,可以改寫成 expression body,跟 stdlib 的寫法一致

inline fun <T, R> T.myLet(block: (T) -> R): R = block(this)

inline fun <T, R> T.myRun(block: T.() -> R): R = block()

兩個都標 inline,因為 Lambda 在函式裡面直接執行(不會被存起來),正好符合 day 04 講的 inline 使用條件。stdlib 還多了 contract,這部分留到本篇後面的「與 stdlib 原始碼比較」一起看

TDD 實作 myWith、myApply、myAlso

Red:先寫測試

@Test
fun `with operates on receiver`() {
    val result = myWith("hello") { length }
    assertEquals(5, result)
}

@Test
fun `apply returns the object itself`() {
    val list = ArrayList<Int>().myApply {
        add(1)
        add(2)
        add(3)
    }
    assertEquals(listOf(1, 2, 3), list)
}

@Test
fun `also for side effects in chain`() {
    var sideEffect = ""
    val result = listOf(1, 2, 3)
        .filter { it > 1 }
        .myAlso { sideEffect = "filtered: $it" }
        .map { it * 10 }
    assertEquals(listOf(20, 30), result)
    assertEquals("filtered: [2, 3]", sideEffect)
}

@Test
fun `apply on empty collection returns the same instance`() {
    // 邊界案例:空集合上 apply,Lambda 什麼都不加,仍回傳原物件
    val empty = ArrayList<Int>()
    val result = empty.myApply { }
    assertSame(empty, result)
    assertEquals(emptyList<Int>(), result)
}

@Test
fun `also runs on nullable receiver via safe call`() {
    // 例外/null 行為:nullable receiver 用 ?. 接 also,null 時整段不執行
    var sideEffect = "untouched"
    val nothing: String? = null
    val result = nothing?.myAlso { sideEffect = "ran with $it" }
    assertNull(result)
    assertEquals("untouched", sideEffect)
}

Green:最小實作

inline fun <T, R> myWith(receiver: T, block: T.() -> R): R {
    return receiver.block()
}

inline fun <T> T.myApply(block: T.() -> Unit): T {
    block()
    return this
}

inline fun <T> T.myAlso(block: (T) -> Unit): T {
    block(this)
    return this
}

myWithmyRun 很像,差在 with 是頂層函式,receiver 用參數傳入。myApplythis 當 receiver,回傳 thismyAlsoit 存取物件,也回傳 this

Refactor:往 stdlib 的寫法靠近

myWith 是單一運算式,一樣改成 expression body。myApplymyAlso 因為先有副作用、再 return this,是兩行邏輯,stdlib 本身也維持區塊寫法,這裡就跟著保留

inline fun <T, R> myWith(receiver: T, block: T.() -> R): R = receiver.block()

整理一下五個函式的實作差異

函式 Lambda 型別 回傳
myLet (T) -> R block(this)
myRun T.() -> R block()
myWith T.() -> R receiver.block()
myApply T.() -> Unit this
myAlso (T) -> Unit this

在 Collection 鏈中的實用組合

let:搭配 null 安全鏈

day 10 講 mapNotNull 時,就用過 ?.let 把 nullable 攤平,那是 let 在 Collection 操作裡第一次登場。這裡把它擴大到整條鏈

val employees = listOf(
    Employee(1, "Alice", "Engineering", 85000, 30),
    Employee(2, "Bob", "Engineering", 72000, 25)
)

// filter 結果可能是空的,用 takeIf + let 處理
employees
    .filter { it.salary > 80000 }
    .takeIf { it.isNotEmpty() }
    ?.let { highPaid -> println("高薪人數: ${highPaid.size}") }

takeIf 回傳 null 或原本的 List,接上 ?.let 只在非 null 時執行。不需要寫 if-else

also:在 pipeline 中插入 debug

listOf(1, 2, 3, 4, 5)
    .filter { it > 2 }
    .also { println("filter 後: $it") }  // debug 用
    .map { it * 10 }
    .also { println("map 後: $it") }     // debug 用

also 回傳物件本身,不影響 pipeline。debug 完直接拿掉就好,不需要改動其他程式碼

非擴充版 run:在 scope 內做複雜操作

當你需要先處理一份 Collection,再把結果組成一段字串或一個物件,可以用非擴充版 run { } 開一個 scope,把中間步驟放在裡面,最後回傳整理好的結果

它與前面手刻的 obj.myRun { } 不同,這個版本沒有 receiver,只是立即執行區塊並回傳最後一個運算式

val employees = listOf(
    Employee(1, "Alice", "Engineering", 85000, 30),
    Employee(2, "Bob", "Sales", 72000, 25)
)

val report = run {
    val byDept = employees.groupBy { it.department }
    buildString {
        byDept.forEach { (dept, members) ->
            appendLine("$dept: ${members.size} 人")
        }
    }
}

run 回傳 Lambda 最後一行的結果,這裡就是 buildString 產出的字串。byDept 這個中間變數只活在 scope 內,不會外洩到周圍,把「先分組、再組字串」這段邏輯包成一個運算式

順帶一提,apply 在這種場景常拿來初始化集合,例如 HashMap<String, Any>().apply { put(...) },回傳物件本身、可以直接賦值,差別在 run 回傳的是計算結果,apply 回傳的是物件本身

過度使用的反模式

scope function 很方便,但濫用會讓程式碼變難讀。兩個常見的反模式

let 巢狀堆疊

// ❌ 三層 let,縮排和 ?. 鏈一樣多,沒省到事
employee?.let { e ->
    e.address?.let { a ->
        a.city?.let { c -> println(c) }
    }
}

// ✅ 直接用 ?. 鏈
employee?.address?.city?.let(::println)

apply 空殼

// ❌ Lambda 裡面什麼都沒做
val name = "Alice".apply { /* nothing */ }

apply 的價值在「對 receiver 做多個操作」,Lambda 是空的或只有一行,直接賦值更清楚

挑選 scope function 的判斷順序

  1. 只是要把 nullable 攤平 → ?.
  2. 要對同一物件做多次操作 → apply(初始化)、with(讀取)
  3. 要在 pipeline 中途插入 debug 或 log → also
  4. 要做 null-safe 的轉換 → ?.let
  5. 其他情況直接寫普通的 if/賦值,不要硬套 scope function

與 stdlib 原始碼比較

原始碼位置:kotlinStandard.kt

stdlib 的主體邏輯與手刻版本相同

// stdlib 的 let
public inline fun <T, R> T.let(block: (T) -> R): R {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    return block(this)
}

唯一的差別是 stdlib 加了 contractcallsInPlace(block, EXACTLY_ONCE) 告訴 compiler:這個 Lambda 一定會被執行正好一次。這讓 let 裡面可以初始化 val 變數

小結

五大 Scope Functions 的實作都只有一兩行

差異在 Lambda 型別與回傳值:(T) -> R 使用 itT.() -> R 使用 thisletrunwith 回傳 Lambda 結果,applyalso 回傳物件本身。搭配 Collection 操作時,let 可處理 null 安全鏈,also 適合插入 debug,run 則能把多步驟計算收進同一個 scope

下一篇深入 operator 和 infix,看怎麼讓自訂型別支援 []+ 等運算子語法

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 31:Sequence 效能分析與使用時機總結
下一篇
Kotlin Lambda 從零開始 Day 33:自訂運算子與中綴函式 — 讓 Collection 操作更優雅
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言